- Görkem Güray/
- SwiftUI in 100 Days Notes/
- Day 60 - SwiftUI JSON Custom Codable Key and FriendFace Milestone Project/
Day 60 - SwiftUI JSON Custom Codable Key and FriendFace Milestone Project
Table of Contents
Custom Codable Keys #
When our JSON data matches the types we design, Codable
works perfectly. In fact, it’s usually enough that we don’t do anything more than add the Codable
compatibility - the Swift compiler will automatically generate everything we need.
However, often things are not that easy and we have three options for working with more complex data:
- Ask Swift to automatically convert property names.
- Create custom property name transformations.
- Create completely custom encoding and decoding.
In general, you should rank these options in order of preference, with option 1 being the most preferable and option 3 being the least preferable.
Let’s examine the first two options one by one. I will leave option 3 for now as it is comparatively more complex!
Asking Swift to automatically convert property names is useful when the property naming conventions in the incoming JSON are different from those of our Swift code. For example, JSON property names might be snake case (e.g. first_name
), while in our Swift code we might be using camel case (e.g. firstName
).
Codable
can translate between these two formats, for this we need to set a property called keyDecodingStrategy
.
To demonstrate this, we have a User
struct with two properties:
struct User: Codable {
var firstName: String
var lastName: String
}
It uses the naming convention commonly used in Swift code, called “camel case” because the practice of capitalizing the first letters of words resembles the humps on the backs of camels.
Now let’s give a piece of JSON data with the same two properties:
let str = """
{
"first_name": "Andrew",
"last_name": "Glouberman"
}
"""
let data = Data(str.utf8)
This JSON data uses the “snake case” naming convention, where property names are all lowercase and words are separated by underscores.
If we try to decode this JSON into a User
instance, it won’t work, because the two properties use different naming styles:
do {
let decoder = JSONDecoder()
let user = try decoder.decode(User.self, from: data)
print("Hi, I'm \(user.firstName) \(user.lastName)")
} catch {
print("Whoops: \(error.localizedDescription)")
}
However, if we change the key decoding strategy before calling the decode()
method, we can ask Swift to convert snake case to camel case and vice versa. Thus, the decoding will be successful:
do {
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let user = try decoder.decode(User.self, from: data)
print("Hi, I'm \(user.firstName) \(user.lastName)")
} catch {
print("Whoops: \(error.localizedDescription)")
}
This works great for converting from snake_case to camelCase and vice versa, but what if our property names are completely different? That’s when we need the second option, which is to create custom property name conversions.
As an example, let’s look at this JSON:
let str = """
{
"first": "Andrew",
"last": "Glouberman"
}
"""
Here we have the user’s first and last name, but the property names don’t match our struct at all.
When we were looking at Codable
, I mentioned that we could create a CodingKeys
enum that defines the encode and decode keys. At the time I said “this enum is traditionally called CodingKeys
, with an S at the end, but you can call it something else if you want”. But that’s not the whole story.
Actually, the reason we use the name CodingKeys
is that it has special powers: If a CodingKeys
enum exists, Swift automatically determines how an object should behave to be encode and decode, so we don’t have to provide special Codable
implementations.
This can be a bit hard to understand, so it’s better to illustrate with a code example. Try changing the User
struct like this:
struct User: Codable {
enum ZZZCodingKeys: CodingKey {
case firstName
}
var firstName: String
var lastName: String
}
That code is compilable for now, because the name ZZZZCodingKeys
is meaningless for Swift - it’s just a nested enum. But if you rename the enum to just CodingKeys
, the code will no longer compile: We are now only told to encode and decode the firstName
property, which means that there is no initializer that sets the lastName
property - and this is unacceptable.
This is because CodingKeys
has a second superpower: When we add raw value strings to popertys, Swift uses them for JSON property names. That is, case names must match our Swift property names, and case values must match JSON property names.
So, let’s go back to our example JSON:
let str = """
{
"first": "Andrew",
"last": "Glouberman"
}
"""
In this case the JSON property names “first” and “last” are used, while our User
struct uses the firstName
and lastName
properties. This is where CodingKeys
can help us: We don’t need to write a special Codable
compatibility, because we can add coding keys to map our Swift property names to JSON property names, like this:
struct User: Codable {
enum CodingKeys: String, CodingKey {
case firstName = "first"
case lastName = "last"
}
var firstName: String
var lastName: String
}
Now that we have specifically told Swift how to convert between JSON and Swift naming, we don’t need to use keyDecodingStrategy
anymore - just add that enum.
So, you need to know how to create custom Codable
compatibility, but if these other options are possible, it’s usually best practice to use them.
Completely Custom Codable Implementation #
So far, you’ve seen how Swift can map between snake case and camel case, and how we can specify a mapping when JSON has one name and Swift uses a completely different name.
The last option is for when the changes are larger, for example when the JSON data provides a number as a string. But this is also useful when you want it, as you will see how to make SwiftData models conform to Codable
.
First, let’s try a new JSON that demonstrates the problem:
let str = """
{
"first": "Andrew",
"last": "Glouberman",
"age": "13"
}
"""
Here the first and last names have unhelpful names, and a number is stored in a string. We can do little to fix the problems with JSON data coming from a server, but we certainly don’t want their quirks polluting our code - it’s an integer, and we want to store it as an integer in our Swift code.
So, we can fix the firstName
and lastName
properties and define a User
struct that will store age
as an integer:
struct User: Codable {
enum CodingKeys: String, CodingKey {
case firstName = "first"
case lastName = "last"
case age
}
var firstName: String
var lastName: String
var age: Int
}
But now we have a problem: Swift can convert property names for us, but it can’t handle different data types.
In this case, we need to create a completely custom Codable
implementation. For this we need to add two things to the User
struct:
- A new initializer that accepts a
Decoder
instance and knows how to read properties from it. - A new
encode(to:)
method that accepts anEncoder
instance and knows how to write properties there.
Tip: Swift uses Decoder
and Encoder
here because there are many ways to convert data into Swift objects - JSON is just one of them.
Both require quite a lot of code, but Xcode can sometimes help us. In this case, it needs to fill in all the code that will make both work: Type init
under properties, then select init(from decoder: Decoder)
and press Enter, then type encode
and select encode(to encoder: Encoder)
and press Enter.
The completed User
struct should look like this:
struct User: Codable {
enum CodingKeys: String, CodingKey {
case firstName = "first"
case lastName = "last"
case age
}
var firstName: String
var lastName: String
var age: Int
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.firstName = try container.decode(String.self, forKey: .firstName)
self.lastName = try container.decode(String.self, forKey: .lastName)
self.age = try container.decode(Int.self, forKey: .age)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.firstName, forKey: .firstName)
try container.encode(self.lastName, forKey: .lastName)
try container.encode(self.age, forKey: .age)
}
}
Tip: If this was a class instead of a struct, the new initializer would have to be marked as required
so that subclasses would have to implement it.
Yes, that’s a lot of code, but really only four lines are important: two lines from the init(from:)
method and two lines from the encode(to:)
method.
The first line that is important is this line from the initializer:
let container = try decoder.container(keyedBy: CodingKeys.self)
This code reads all possible keys that can be loaded from the JSON file using CodingKeys
. CodingKeys
is looked up in the enum, so things like .firstName
and .age
can be referenced.
This is the second important line, also from the initializer:
self.firstName = try container.decode(String.self, forKey: .firstName)
This code reads a string corresponding to the key .firstName
from JSON and assigns it to the firstName
property of the struct. This part can be a bit confusing because firstName
appears twice, so let me rephrase what the code does: ‘Find the property corresponding to CodingKeys.firstName
in JSON and assign it to our local firstName
value.’
This small step is important, because CodingKeys.firstName
is not actually firstName
, because we renamed it to match our JSON. So, in reality, this line means ‘Find the first
property in JSON and assign it to the firstName
property of our structure’ - to make sure that the automatic renaming still happens.
To help, imagine that you could read the code like this:
self.structFirstName = try container.decode(String.self, forKey: .jsonFirstName)
This is the first two interesting lines of code. The second two lines effectively do the opposite of the first two. They are in the encode(to:)
method:
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.firstName, forKey: .firstName)
This first line means that we want to create a place where we can store all our CodingKeys
values, and the second line writes the existing firstName
property to the one specified in CodingKeys.firstName
- here it is important that the automatic renaming is done to first
.
At this point, you’re probably wondering if you’ll ever remember this code, because it’s not predictable. So, let me give you my most important tip:
**When you need to implement a custom Codable
application and Xcode can’t create it for you, create a new, simple structure with a single-feature, single-state CodingKeys
enum, build your own application using that application that Xcode created.
This is especially important when working with SwiftData, because adding Codable
support means creating a custom application. Remembering all the code above is tedious and Xcode certainly won’t help, so create a temporary structure for a Codable
implementation that Xcode could create, then use that structure to make your SwiftData model class Codable
.
Anyway, we got to this point because we tried to load a string into an integer, which required us to make two changes to the code that Xcode generates. First, this line of code needs to be changed:
self.age = try container.decode(Int.self, forKey: .age)
This tries to read the age
property as an integer and fails. Instead, we need to read it as a string, then convert it to an integer, or provide a default value if the conversion fails. Replace the code with this:
let stringAge = try container.decode(String.self, forKey: .age)
self.age = Int(stringAge) ?? 0
The second thing that needs to be changed is the encode(to:)
line, so that if we need to write any JSON we keep the current format. Here, this line needs to be changed:
try container.encode(self.age, forKey: .age)
It writes an integer, but it should write a string like this one:
try container.encode(String(self.age), forKey: .age)
I know that creating a custom implementation seems like a lot of hassle, but as you can see it gives us full control over what happens: we can add all kinds of logic to our upload and save, change names, change types, provide default values and more.
FriendFace Milestone Project #
Here it’s time to build an application from scratch, and today is a particularly comprehensive challenge: your task is to use URLSession
to download some JSON from the internet, use Codable
to convert them into Swift types, then use NavigationStack
, List
and more to show them to the user.
Your first step should be to examine the JSON. The URL you want to use is: https://www.hackingwithswift.com/samples/friendface.json - this is a large collection of randomly generated data for sample users.
As you can see, there is an array of people and each person has an ID, name, age, email address and more. They also have a set of tag arrays and a set of friends, each with a name and ID.
How much you implement this is up to you, but you should at least do the following:
- Take the data and parse it into
User
andFriend
structures. - Display a list of users with some information about them, such as their name and whether they are currently active.
- Create a detail view that is shown when a user is tapped, offering more information about them, including the names of their friends.
- Before you start the download, check that your
User
directory is empty so you don’t start the download over and over again each time the view is shown.
If you are not sure where to start, start by designing your types: Create a User
structure with name
, age
, company
and so on, then a Friend
structure with id
and name
. After that, move on to some URLSession
code to get the data and decode it into your types.
You may notice that the date each user registered has a very specific format: 2015-11-10T01:47:18-00:00. This is known as ISO-8601 and is so common that there is a built-in dateDecodingStrategy
called .iso8601
that decodes it automatically.
When creating this, I want you to keep one thing in mind: this kind of implementation is a must for iOS app development - if you can do it with confidence, you are well on your way to becoming a full-time app developer.
**As always, the best way to solve this challenge is to keep it simple - write as little code as possible to solve the challenge and make sure it works well.
Solution #
The solution for this project is available on GitHub below;
https://github.com/GorkemGuray/FriendFace
You can also read this article in Turkish.
Bu yazıyı Türkçe olarak da okuyabilirsiniz.